iT邦幫忙

2024 iThome 鐵人賽

DAY 10
0
自我挑戰組

30 天 vueuse 原始碼閱讀與實作系列 第 10

[Day 10] useEventListener - unit test

  • 分享至 

  • xImage
  •  

今天來講 useEventListener 的單元測試,大部分是針對傳入不同型別的參數做的測試。相關說明會放在程式碼註解中,如果有遇到需要描述更多的會再放到程式碼區塊外面~

在寫測試之前

DAY 8 的時候有介紹過參數以及實作參數處理的部分,那時候用比較簡單的方式呈現,現在因為 unit test 大部分都是針對傳入的參數做對應的測試,所以那時候簡易實作版本會造成有些測試失敗,在這邊先做一下調整,以下貼上修改後的 useEventLisnener,邏輯部分都沒變,也可以參考本文最後的 GitHub PR 看詳細的差別~

// src/compositions/useEventListener.js
export function useEventListener(...args) {
  let target
  let events
  let listeners
  let options

  if (typeof args[0] === 'string' || Array.isArray(args[0])) {
    [events, listeners, options] = args
    target = defaultWindow
  }
  else {
    [target, events, listeners, options] = args
  }

  if (!target)
    return noop

  if (!Array.isArray(events))
    events = [events]
  if (!Array.isArray(listeners))
    listeners = [listeners]

  // 用來收集 removeEventListener function
  const cleanups = []
  const cleanup = () => {
    cleanups.forEach(cleanup => cleanup())
    cleanups.length = 0
  }

  const register = (el, event, listener, options) => {
    el.addEventListener(event, listener, options)
    return () => el.removeEventListener(event, listener, options)
  }

  const stopWatch = watch(
    () => [unrefElement(target), toValue(options)],
    ([el, options]) => {
      cleanup()
      if (!el)
        return

      const optionsClone = isObject(options) ? { ...options } : options
      cleanups.push(...events.flatMap((event) => {
        return listeners.map(listener => register(el, event, listener, optionsClone))
      }))
    },
    { immediate: true, flush: 'post' },
  )

  const stop = () => {
    stopWatch()
    cleanup()
  }

  tryOnScopeDispose(stop)

  return stop
};

基本測試結構

後續的案例都會放在 // 測試案例這個註解的層級位置

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isVue2, nextTick, ref } from 'vue'
import { useEventListener } from './useEventListener'
import { noop } from '@/helper'

describe('useEventListener', () => {
  const options = { capture: true }
  let stop
  let target
  let removeSpy
  let addSpy

  beforeEach(() => {
    target = document.createElement('div')
    removeSpy = vi.spyOn(target, 'removeEventListener')
    addSpy = vi.spyOn(target, 'addEventListener')
  })

  // 測試案例
})

removeSpyaddSpy 用 spyOn 的方式 mock target 物件的 removeEventListeneraddEventListener method,後續如果要測試這兩個 method 是否有被觸發,可以用以下方式做測試:

expect(removeSpy).toBeCalledTimes(1)
expect(addSpy).toBeCalledTimes(1)

因為是放在 beforeEach 中,所以每次跑測試案例時,removeSpyaddSpy 都會被重新建立,可以保持測試案例的一致性。

測試參數 - ㄧ個 event & 一個 listener

先從最單純的參數開始測試,eventlistener 都不是陣列(只有一個)

describe('give both none array', () => {
    // mock listener function
    const listener = vi.fn()
    const event = 'click'

    beforeEach(() => {
      listener.mockReset()
      stop = useEventListener(target, event, listener, options)
    })

    it('should add listener', () => {
      // 因為 beforeEach 有執行一次 useEventListener,所以 target 的 addEventListener 被執行一次
      expect(addSpy).toBeCalledTimes(1)
    })

    it('should trigger listener', () => {
      expect(listener).not.toBeCalled()
      target.dispatchEvent(new MouseEvent('click'))
      // target 觸發 click event 後,listener 被執行一次
      expect(listener).toBeCalledTimes(1)
    })

    it('should remove listener', () => {
      expect(removeSpy).not.toBeCalled()
      stop()
      // 執行 useEventListener  return 的 stop function,target 的 removeEventListener 被執行一次
      expect(removeSpy).toBeCalledTimes(1)
      // 檢查 target 的 removeEventListener 被執行的時候,參數是否跟之前 addEventListener 的參數一樣
      expect(removeSpy).toBeCalledWith(event, listener, options)
    })
})

should add listener, should trigger listener, should remove listener 這三個案例會在後續一直出現。測試的內容差不多,只是會因為參數不同,內部實作稍有不同,有些直觀的測試就純粹在這邊列出,應該也不太需要註解或說明~

測試參數 - 多個 event & 一個 listener

describe('given array of events but single listener', () => {
    const listener = vi.fn()
    const events = ['click', 'scroll', 'blur', 'resize']

    beforeEach(() => {
      listener.mockReset()
      stop = useEventListener(target, events, listener, options)
    })

    it('should add listener for all events', () => {
      events.forEach(event => expect(addSpy).toBeCalledWith(event, listener, options))
    })

    it('should trigger listener with all events', () => {
      expect(listener).not.toBeCalled()
      events.forEach((event, index) => {
        target.dispatchEvent(new Event(event))
        expect(listener).toBeCalledTimes(index + 1)
      })
    })

    it('should remove listener with all events', () => {
      expect(removeSpy).not.toBeCalled()

      stop()

      expect(removeSpy).toBeCalledTimes(events.length)
      events.forEach(event => expect(removeSpy).toBeCalledWith(event, listener, options))
    })
})

測試參數 - ㄧ個 event & 多個 listener

describe('given single event but array of listeners', () => {
    const listeners = [vi.fn(), vi.fn(), vi.fn()]
    const event = 'click'

    beforeEach(() => {
      listeners.forEach(listener => listener.mockReset())
      stop = useEventListener(target, event, listeners, options)
    })

    it('should add all listeners', () => {
      listeners.forEach(listener => expect(addSpy).toBeCalledWith(event, listener, options))
    })

    it('should call all listeners with single click event', () => {
      listeners.forEach(listener => expect(listener).not.toBeCalled())

      target.dispatchEvent(new Event(event))

      listeners.forEach(listener => expect(listener).toBeCalledTimes(1))
    })

    it('should remove listeners', () => {
      expect(removeSpy).not.toBeCalled()

      stop()

      expect(removeSpy).toBeCalledTimes(listeners.length)
      listeners.forEach(listener => expect(removeSpy).toBeCalledWith(event, listener, options))
    })
})

測試參數 - 多個 event & 多個 listener

describe('given both array of events and listeners', () => {
    const listeners = [vi.fn(), vi.fn(), vi.fn()]
    const events = ['click', 'scroll', 'blur', 'resize', 'custom-event']

    beforeEach(() => {
      listeners.forEach(listener => listener.mockReset())
      stop = useEventListener(target, events, listeners, options)
    })

    it('should add all listeners for all events', () => {
      listeners.forEach((listener) => {
        events.forEach((event) => {
          expect(addSpy).toBeCalledWith(event, listener, options)
        })
      })
    })

    it('should call all listeners with all events', () => {
      events.forEach((event, index) => {
        target.dispatchEvent(new Event(event))
        listeners.forEach(listener => expect(listener).toBeCalledTimes(index + 1))
      })
    })

    it('should remove all listeners with all events', () => {
      expect(removeSpy).not.toBeCalled()

      stop()

      listeners.forEach((listener) => {
        events.forEach((event) => {
          expect(removeSpy).toBeCalledWith(event, listener, options)
        })
      })
    })
})

測試例外情境

後續的案例都會放在 // 例外情境測試案例這個註解的層級位置

describe('multiple events', () => {
    let target
    let listener

    beforeEach(() => {
      target = ref(document.createElement('div'))
      listener = vi.fn()
    })

   // 例外情境測試案例
})

傳入非法格式,listener 不能被執行

it('should not listen when target is invalid', async () => {
    useEventListener(target, 'click', listener)
    const el = target.value
    target.value = null
    await nextTick()
    el?.dispatchEvent(new MouseEvent('click'))
    await nextTick()

    expect(listener).toHaveBeenCalledTimes(0)
    expect(useEventListener(null, 'click', listener)).toBe(noop)
})

這個單元測試案例滿好玩的,
昨天有提到 target 可以傳入 ref 物件,target 也正好是 watch 觀察的對象,所以當程式碼執行到 target.value = null 時,會執行 watch callback 裡面的邏輯:

// src/compositions/useEventListener.js
const stopWatch = watch(
    () => [unrefElement(target), toValue(options)],
    ([el, options]) => {
      cleanup()
      if (!el)
        return

就在這邊被 return 掉了,而第一次因為 { immediate: true } 註冊的那些監聽器,也在 return 之前被 cleanup() 清掉了,所以測試程式執行到 el?.dispatchEvent(new MouseEvent('click')) 的時候就不會再觸發 listener。

分別測試傳入沒傳 target 或傳入 div element 情境

function getTargetName(useTarget) {
  return useTarget ? 'element' : 'window'
}

function getArgs(useTarget) {
  return (useTarget ? [target, 'click', listener] : ['click', listener])
}

function trigger(useTarget) {
  (useTarget ? target.value : window).dispatchEvent(new MouseEvent('click'))
}

function testTarget(useTarget) {
  it(`should ${getTargetName(useTarget)} listen event`, async () => {
    useEventListener(...getArgs(useTarget))

    trigger(useTarget)

    await nextTick()

    expect(listener).toHaveBeenCalledTimes(1)
  })

  it(`should ${getTargetName(useTarget)} manually stop listening event`, async () => {
    const stop = useEventListener(...getArgs(useTarget))
    
    stop()

    trigger(useTarget)

    await nextTick()

    // 測試手動執行 stop() 後觸發的事件,listener 不能被執行
    expect(listener).toHaveBeenCalledTimes(0)
  })

  it(`should ${getTargetName(useTarget)} auto stop listening event`, async () => {
    const scope = effectScope()
    await scope.run(async () => {
      useEventListener(...getArgs(useTarget))
    })

    await scope.stop()

    trigger(useTarget)

    await nextTick()

    expect(listener).toHaveBeenCalledTimes(isVue2 ? 1 : 0)
  })
}

testTarget(false) // 不傳 target,target 預設必須是 window
testTarget(true) // target 是 div element

這段有點大段,測試起點在 testTarget(false)testTarget(true) ,可參考相關註解。

這段最有趣的就是 should element auto stop listening event 這個案例了(因為變數有點難讀,這邊先用 element 取代原本變數的部分),這個案例的測試程式碼我底下再寫一次:

it('should element auto stop listening event', async () => {
    const scope = effectScope()
    await scope.run(async () => {
      useEventListener(...getArgs(useTarget))
    })

    await scope.stop()

    trigger(useTarget)

    await nextTick()

    expect(listener).toHaveBeenCalledTimes(isVue2 ? 1 : 0)
})

前面自己手動建立一個 effect scope,然後透過 await scope.stop() 來觸發我們 DAY 8 看到的 tryOnScopeDispose
所以後面觸發事件,listener 都不會再被執行。然後看看這個 isVue2 ? 1 : 0 ,顯然 Vue2 沒有 effectScope() 這種東西 XD

關於 effectScope(),可以參考官方文件說明:https://cn.vuejs.org/api/reactivity-advanced#effectscope

測試 target, options 這兩個 watch 的對象改變後的行為

it.skipIf(isVue2)('should auto re-register', async () => {
    const target = ref()
    const listener = vi.fn()
    const options = ref(false)
    useEventListener(target, 'click', listener, options)

    const el = document.createElement('div')
    const addSpy = vi.spyOn(el, 'addEventListener')
    const removeSpy = vi.spyOn(el, 'removeEventListener')
    target.value = el
    await nextTick()
    expect(addSpy).toHaveBeenCalledTimes(1)
    expect(addSpy).toHaveBeenLastCalledWith('click', listener, false)
    expect(removeSpy).toHaveBeenCalledTimes(0)

    options.value = true
    await nextTick()
    expect(addSpy).toHaveBeenCalledTimes(2)
    expect(addSpy).toHaveBeenLastCalledWith('click', listener, true)
    expect(removeSpy).toHaveBeenCalledTimes(1)
})

接下來可以到 Day9 的 register & cleanup 區塊,對照著看比較有感覺。

target.value 一開始是 undefined,進到 watch callback 會被 return,所以不會註冊任何東西。
測試程式執行到 target.value = el 的時候觸發 watch callback,因為第一次執行沒註冊任何東西,所以 cleanups array 是空的,removeSpy 也就不會被執行到。

測試程式執行到 options.value = true 的時候觸發 watch callback,這次會把上次註冊的東西都清掉,所以 removeSpy 會被執行。

GitHub:https://github.com/RhinoLee/30days_vue/pull/9/files


因為是最近工作才開始寫 unit test,在看 unit test 的 source code 總是可以學到很多用法,如果後面沒有想看的 API,可以一直看 unit test 就好(誤)

useEventListener 在這邊也搞一段落了,明天會開始看 useMouse~


上一篇
[Day 9] useEventListener - 主要流程
下一篇
[Day 11] useMouse - 基本功能
系列文
30 天 vueuse 原始碼閱讀與實作13
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言